Skip to content

feat(core): Aggregate TurboModule call counts and latency per (module, method, kind)#6377

Merged
alwx merged 10 commits into
mainfrom
alwx/features/aggregated-stats
Jul 16, 2026
Merged

feat(core): Aggregate TurboModule call counts and latency per (module, method, kind)#6377
alwx merged 10 commits into
mainfrom
alwx/features/aggregated-stats

Conversation

@alwx

@alwx alwx commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

📢 Type of change

  • New feature

📜 Description

Adds per-(module, method, kind) aggregation for TurboModule invocations on top of the existing wrapTurboModule instrumentation. Aggregates flush at two points:

  1. On transaction finish — a synthetic turbo_modules.aggregate child span on the event carries the per-method breakdown in span attributes; headline measurements (turbo_modules.call_count, .error_count, .total_ms, .top_module_ms) land on the root span for the standard Measurements panel.
  2. Lazy timer (default 30s) — emits an info-level event tagged event.kind=turbo_modules.aggregate so idle sessions without transactions still produce a signal. Armed only on the first record after a drain, so silent sessions don't churn timers.

New turboModuleContextIntegration options: enableAggregateStats (default true), aggregateFlushIntervalMs (default 30000, 0 disables), ignoreTurboModules (excludes modules from counters; still wrapped for crash attribution).

O(1) per call: counters + 6-bucket histogram (<1ms, <5ms, <20ms, <100ms, <500ms, >=500ms). Failures inside the aggregator never break the wrapped TurboModule call.

💡 Motivation and Context

Closes #6164. Per-call spans for every TurboModule invocation would explode span counts on hot async paths; aggregated counters are the right tradeoff.

💚 How did you test it?

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • feat(core): Aggregate TurboModule call counts and latency per (module, method, kind) by alwx in #6377
  • chore(deps): bump the codeql-action group with 2 updates by dependabot in #6461
  • fix(core): Fix stale route when reactNavigationIntegration uses a route override provider by alwx in #6458
  • chore(deps): update Android SDK to v8.49.0 by github-actions in #6459
  • feat(android): Add enableHistoricalTombstoneReporting option by Cryptoteep in #6450
  • docs(sdk-versions): Add 8.14.2 row to main by alwx in #6456
  • docs(changelog): Add 8.14.2 changelog entry by alwx in #6455
  • chore(deps): bump ruby/setup-ruby from 1.314.0 to 1.316.0 by dependabot in #6446
  • chore(deps): bump actions/setup-java from 5.3.0 to 5.5.0 by dependabot in #6444
  • chore(deps): bump github/codeql-action/analyze from 4.36.3 to 4.37.0 by dependabot in #6443
  • chore(deps): bump dorny/paths-filter from 4.0.1 to 4.0.2 by dependabot in #6442
  • chore(deps): update JavaScript SDK to v10.65.0 by github-actions in #6441
  • fix(ios): Break RNSentryOnDrawReporterView retain cycle in emit-new-frame block by alwx in #6449
  • refactor(ios): Migrate from PrivateSentrySDKOnly to SentrySDK.internal by alwx in #6380
  • docs: Improve PR template guidance by sentry-junior in #6447
  • fix(ios): Resolve getNewScreenTimeToDisplay Promise with number not array by antonis in #6438
  • fix(feedback): Guard show/hide against uninitialized _setVisibility by antonis in #6435
  • fix(tracing): Fix orphaned TTID/TTFD spans in the trace view by antonis in #6437
  • feat(tracing): Add reportFullyDisplayed() static API by antonis in #6419
  • feat(core): Track CaptureAppStartErrors adoption by antonis in #6429
  • feat(replay): Track mobile replay network capture adoption by antonis in #6428
  • feat(tracing): Track standalone app start adoption by antonis in #6427
  • feat(tracing): Track appLoaded and extendAppStart adoption by antonis in #6426
  • feat(tracing): Track wrapExpoRouterErrorBoundary adoption by antonis in #6425

Plus 2 more


🤖 This preview updates automatically when you update the PR.

alwx and others added 2 commits June 30, 2026 11:20
…, method, kind)

Adds a small fixed-bucket histogram + counters per `(module, method, kind)`
fed by the existing `wrapTurboModule` instrumentation. Aggregates flush on
transaction finish (synthetic `turbo_modules.aggregate` child span + headline
measurements on the root span) and on a lazy timer (info-level event for
long-running sessions without transactions).

Closes #6164.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop edge-case tests in favor of one happy-path check per surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alwx
alwx force-pushed the alwx/features/aggregated-stats branch from 933fdb9 to 5702b46 Compare June 30, 2026 09:21
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 214105a

Comment thread packages/core/src/js/turbomodule/turboModuleAggregator.ts
@alwx
alwx marked this pull request as ready for review July 13, 2026 12:43
@alwx
alwx requested review from a team, antonis and lucas-zimerman as code owners July 13, 2026 12:43
@alwx alwx self-assigned this Jul 13, 2026
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
The periodic flush's own `captureEvent` travels through the wrapped
`RNSentry.captureEnvelope`, which records back into the aggregator and,
because the map was just drained, re-armed the flush timer forever in
otherwise-idle sessions. Suppress the empty→non-empty callback around
the flush's `captureEvent`, deferring the release to the next macrotask
so the async record fired from the transport's `.then()` also lands
inside the suppression window.
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
Comment thread packages/core/src/js/turbomodule/wrapTurboModule.ts
`enableAggregateStats: false` skipped flush setup and processEvent but
`wrapTurboModule` still recorded every call into the process-wide map,
which had no drain path in that mode — so counters grew for the app's
lifetime. Aggregator now exposes a master `setAggregateRecordingEnabled`
switch that both no-ops future records and evicts existing entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread packages/core/src/js/integrations/turboModuleContext.ts Outdated
@lucas-zimerman

lucas-zimerman commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

@alwx using some agents locally, it spotted the following, would you be able to verify on your side?

Trace this in order:

  1. Line 283: drainTurboModuleAggregate() — the map is now empty.
  2. Line 287: beginSuppressFirstTurboModuleRecordCallback() → bumps suppressFirstRecordCallbackDepth to 1.
  3. Line 289: client.captureEvent(...) — sending this event itself goes through RNSentry.captureEnvelope, which is a wrapped TurboModule (that's the whole point of this SDK — every native bridge call gets wrapped). So this call goes through the exact same recordTurboModuleCall you saw in Step 1.
  4. Back in recordTurboModuleCall: at line 131, wasEmpty = aggregates.size === 0 → true (we just drained in step 1). The write happens anyway (lines 135-162) — one entry for RNSentry.captureEnvelope gets added back into the map. At line 164, the callback check sees suppressFirstRecordCallbackDepth === 0 is false (it's 1), so onFirstRecordAfterEmpty() is correctly not called. Good — that part works as designed.
  5. Line 304: suppression ends (deferred one tick).

Step 4 — the payoff: what happens to the next real call

Now the map isn't empty anymore — it has that one leftover RNSentry.captureEnvelope entry sitting in it, untouched, forever (or until something else drains it).

The next time a real TurboModule call happens (e.g. the user's own native module call), go back to Step 1, line 131:

const wasEmpty = aggregates.size === 0; // FALSE — the leftover entry is still there

wasEmpty is false, so the whole if (wasEmpty && onFirstRecordAfterEmpty && ...) block at line 164 is skipped — not because of suppression this time, but because the map genuinely isn't empty. The callback that arms the next 30-second timer never fires again.

End state: periodic flush fired exactly once, quietly planted one leftover entry as its own exhaust, and that leftover permanently blocks the "empty → non-empty" trigger from ever detecting fresh work again — unless a transaction happens to fire processEvent and drain the map back to zero (which restarts the cycle, but only for one more round).

The fix in one line: at aggregator line 164, also gate the write on suppression, not just the callback — i.e. bail out at the top of recordTurboModuleCall when suppressFirstRecordCallbackDepth > 0, so the periodic flush's own self-call never gets recorded as if it were real user traffic.

The suppression window in `flushPeriodicAggregate` prevented the flush's
own transport call from re-arming the timer, but left the recorded
self-noise sitting in the aggregate map. The next real user call then
saw `wasEmpty=false` and never fired `onFirstRecordAfterEmpty`, so the
periodic timer stayed silent for the rest of the session (until a
transaction drain cleared the leftover).

Drain the aggregator when releasing the suppression scope. Trades a
sub-millisecond window of possibly-real user records for a working
lazy-timer re-arm on every subsequent call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment thread packages/core/src/js/integrations/turboModuleContext.ts Outdated
Comment thread packages/core/src/js/integrations/turboModuleContext.ts
alwx and others added 2 commits July 16, 2026 10:51
The previous suppression + drain machinery worked around the flush's
own `captureEvent → RNSentry.captureEnvelope` self-noise by drop-listing
records around the send. That approach had two follow-on bugs: the
blanket drain also discarded real user calls that raced with the flush
window, and the async transport `.then()` sometimes fired outside the
suppression scope entirely.

Move the fix upstream: default `ignoreTurboModules` to `['RNSentry']`.
Self-noise never enters the aggregator, so no suppression/drain gymnastics
are needed. Users who want RNSentry stats can pass `ignoreTurboModules: []`.

Also document the intentional data-loss trade-off when a transaction is
dropped by `beforeSendTransaction` (bounded, self-healing on the next
transaction or periodic flush).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alwx
alwx requested a review from lucas-zimerman July 16, 2026 09:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8d5c7ae. Configure here.

Comment thread packages/core/src/js/turbomodule/turboModuleAggregator.ts Outdated
alwx and others added 2 commits July 16, 2026 11:53
Nothing in the package reads it — `recordTurboModuleCall` checks the
ignored set inline. Removing dead API surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@lucas-zimerman lucas-zimerman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Once ci test pass

@alwx alwx added the ready-to-merge Triggers the full CI test suite label Jul 16, 2026
@sentry

sentry Bot commented Jul 16, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
Sentry RN io.sentry.reactnative.sample 8.18.0 (98) Release

⚙️ sentry-react-native Build Distribution Settings

@alwx
alwx merged commit 32efa23 into main Jul 16, 2026
106 of 127 checks passed
@alwx
alwx deleted the alwx/features/aggregated-stats branch July 16, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Aggregated per-module / per-method stats

2 participants